#!/bin/bash

####### mugsyback
#
#	author:		j@mesrobertson.com
#	version:	1.0
#
#	purpose:	an rsync and tar backup script with support for lvm volumes.
#			typically used to backup to external usb disks but could be adapted to do more
#
#	configuration:	This script relies on syslog (or rsyslog) and logrotate for the logs.
#			add the following to your syslog daemon, configuration depends on how your syslog is setup.
#			I setup rsyslog as follows:
#
#			/etc/rsyslog.d/mugsyback.conf
#			local1.notice	/var/log/mugsyback.log
#			local1.debug	/dev/null
#
#			then configure logrotate:
#
#			/etc/logrotate.d/mugsyback 
#			/var/log/mugsyback.log {
#			rotate 8
#			weekly
#			compress
#			missingok
#			notifempty
#			}
#
#			remember to restart these daemons.
#			
#
#	notes:		if you rerun the backup on the same day the previous backup folders should be preserved.
#			if its a new day then the oldest directory will be removed unless backup rotation = 1
#			logrotate
#			max of 100 back sources
#
#	dev notes:	no I don't need backupResult=2 in trap because its a filaure not a wanring
#			there might be a use for allowing different destinations for each backupSource by setting some of the global variables
#			within the backupsource sections
#			revise emaillog as its a little hackish
#			need to allow file extension variable for tar backups
#			should allow for remote backups over ssh etc.
#			close file descriptors 62 and 63
#			http://www.unix.com/shell-programming-scripting/144077-bash-closing-file-descriptors-process.html
#			tar writes to stderr
#			http://www.unix.com/shell-programming-scripting/144130-tar-totals-writes-stderr-not-stdout.html
#			how to generate excludes
#			http://www.unix.com/shell-programming-scripting/143825-problem-echo-inserting-single-quotes.html
#			sysLogger function
#			http://www.unix.com/shell-programming-scripting/143700-stout-stderr-syslog-via-function-if-statement.html
#
####### 


####### global script variables do not alter unless you know what your doing
scriptName=$(basename $0)
mugsybackConf=/etc/mugsyback.conf
logLevel=
logToStandardOut=
backupResult=0
snapshotFailed=0
enableEmail=0
rsyncDeleteDest="false"


####### trap user exit and cleanup 
trap 'backupResult=1;cleanup' 1 2


####### print usage and bail
usageAndBail()
{
	cat << EOT
Usage: mugsyback [OPTION]...

   -c	config file path (default /etc/mugsyback.conf)
   -d	enable debugging mode
   -e	enable email reports
   -l	also log to stdout

EOT
	exit 1 ;
}


####### command line arguments
while getopts "c:del" o ; do
        case "$o" in

		c)	mugsybackConf="$OPTARG"	;;
		d)	logLevel=debug ;;
		e)	enableEmail=1 ;;
		l)	logToStandardOut=-s ;;
		:)	echo "Option -${OPTARG} requires an argument." ; exit 1 ;;
		*)	usageAndBail ;;
        esac
done


####### logging
sysLogger ()
{
	local logPriority=$1
        local logMessage=$2
        case "${logPriority}" in

                critical)	logger ${logToStandardOut} -p local1.notice -t ${scriptName}.crit ${logMessage} ;;
                warning)	logger ${logToStandardOut} -p local1.notice -t ${scriptName}.warn ${logMessage} ;;  
                info)		logger ${logToStandardOut} -p local1.notice -t ${scriptName}.info ${logMessage} ;;  
                debug)          if test "${logLevel}" = "debug" ; then
                                        logger ${logToStandardOut} -p local1.notice -t ${scriptName}.debug ${logMessage}
					else	
					logger -p local1.debug -t ${scriptName}.debug ${logMessage}
                                fi ;;  
                *)              echo "logging option does not exist" ;;  
        esac
}


####### timers
startTimer()
{
        START_TIME=$(date)
        S_TIME=$(date +%s)
}

endTimer()
{
        END_TIME=$(date)
        E_TIME=$(date +%s)
        DURATION=$(echo $((E_TIME - S_TIME)))

        #calculate overall completion time
        if [ ${DURATION} -le 60 ]; then
                sysLogger "info" "Backup Duration: ${DURATION} Seconds"
        else
                sysLogger "info" "Backup Duration: $(awk 'BEGIN{ printf "%.2f\n", '${DURATION}'/60}') Minutes"
        fi  
}
 

####### output parser
outputParser()
{
	local input=$1
        $(IFS="" ; while read line
        	do
        	sysLogger "info" "$line"
       	done < $input)
}


####### set error level 
errorLevel()
{
        if test "$1" = "warn" ; then
                if test "${backupResult}" -eq "1" ; then
                        backupResult=1
                else
                        backupResult=2
                fi  
        fi  

        if test "$1" = "fail" ; then
                backupResult=1
        fi  
}


####### cleanup function
cleanup()
{
	sync &
	wait
	cd /

	lvmControlSnapshot destroy

	df -H ${backupDeviceMount} > /tmp/${scriptName}-diskuse
	tree -L 2 ${backupDeviceMount}/${backupDir}/${backupSubDir} > /tmp/${scriptName}-folders

	if test "${didScriptMount}" -eq "0" ; then
		sysLogger "warning" "\"${backupDeviceMount}\" mounted before ${scriptName} ran. make sure it is unmounted before being removed."
		errorLevel "warn"
	fi
	if test "${didScriptMount}" -eq "1" ; then
		umount -v ${backupDeviceMount} 2> >(sysLogger "warning") 1> >(sysLogger "debug") || errorLevel "warn"
	fi

	if test "${backupResult}" -eq "2" ; then
		globalBackupResult=WARNINGS
	fi
	if test "${backupResult}" -eq "1" ; then
		globalBackupResult=FAILURES
	fi
	if test "${backupResult}" -eq "0" ; then
		globalBackupResult=SUCCESS
	fi

	sysLogger "info" "=== BACKUP MEDIA USAGE ==="
	outputParser /tmp/${scriptName}-diskuse
	sysLogger "info" "..."
	outputParser /tmp/${scriptName}-folders
	sysLogger "info" "..."
	sysLogger "info" "${scriptName} Finished at $(date +%F" "%H:%M)"
	loggerEnd=$(date +%s)
	sysLogger "info" "end ${scriptName} at ${loggerEnd}"

	if test "${enableEmail}" -eq "1" ; then
		emailLog
	fi

	rmdir /var/lock/mugsyback
	exit ${backupResult}
}


####### mail reporting 
emailLog ()
{
	ccRecipients=$(IFS=, ; for i in ${mailCC} ; do echo "-c $i " | tr -d "\n" ; done)

	sed -e "0,/${loggerStart}/d" -e "/${loggerEnd}/,\$d" -e "s/$(hostname -s)//" -e "s/${scriptName}.//" "${logOutput}" \
	| mail -s "${clientName} Backup Completed With ${globalBackupResult}" ${mailRecipient} ${ccRecipients} -- -r ${mailSender}
}


####### mount and check backup media
mountBackupTarget()
{
	local backupDevice=$1

	# reset didScriptMount
	didScriptMount=1
	# mount the backup target
	if test "$(grep ${backupDevice} /etc/mtab)" ; then
		sysLogger "debug" "\"${backupDevice}\" already mounted, continuing to test target"; didScriptMount=0
		# perhaps remove cleanup to allow different backup targets per Backup Source but will break backups
		else mount -v ${backupDevice} 2> >(sysLogger "critical") 1> >(sysLogger "debug") || { errorLevel "fail" ; cleanup ; }
	fi
	
	# check backup media is prepared
	if test -f ${backupDevice}/${backupDir}/${checkFile} ; then
		sysLogger "debug" "check file found, continuing..."
		else sysLogger "critical" "check file not found, prepare the media by creating \"/${backupDir}\" and place \"${checkFile}\" in it."
		errorLevel "fail" ; cleanup
	fi
}


####### rotate backup function 
checkBackupRotation()
{
        local backupDirPath=$1

        # default rotation if variable is not defined
        if test -z ${backupRotationCount} ; then
                backupRotationCount=1
        fi
	
	# logic for folder creation
	test -d "${backupDirPath}" || mkdir ${backupDirPath}
	test "$(ls -A ${backupDirPath})" && \
	sysLogger "debug" "\"${backupDirPath}\" exists and contains folders" || mkdir ${backupDirPath}/${backupDirFormat}

	rsyncLinkDest=$(ls -t -1 "${backupDirPath}" | head -1)

	# logic for rsync if using 1 backup rotation
	if test "${backupRotationCount}" -eq "1" ; then 
		test "${rsyncLinkDest}" != "${backupDirFormat}" && mv ${backupDirPath}/${rsyncLinkDest} ${backupDirPath}/${backupDirFormat}
		unset rsyncLinkDest
		else test -d ${backupDirPath}/${backupDirFormat} || mkdir ${backupDirPath}/${backupDirFormat}
	fi

	# rotate backups
        listBackups=$(ls -t "${backupDirPath}")
        backupsToKeep=$(ls -t "${backupDirPath}" | head -"${backupRotationCount}")

	$(IFS='
'
        for i in ${listBackups} ;
        do
                found=0
                for j in ${backupsToKeep} ;
                do
                        if test $i = $j ; then
                                found=1
                        fi
                done

                if test $found -eq 0 ; then
			sysLogger "debug" "Removing $backupDirPath/$i"
			rm -rf "$backupDirPath/$i" 2> >(sysLogger "critical") 1> >(sysLogger "debug") || { errorLevel "fail" ; cleanup ; }
		fi
	done)
}


####### lvm snapshot control
lvmControlSnapshot()
{
        lvmSnapshotName=${lvmName}-snapshot
        case "$1" in

                create)		snapshotFailed=0
				{	exec  62>&- 63>&-
                			lvcreate -s -l ${lvmSnapshotSize}FREE -n ${lvmSnapshotName} ${lvmVolumeGroup}/${lvmName}
				} 2> >(sysLogger "critical") 1> >(sysLogger "debug") || { errorLevel "fail" ; snapshotFailed=1 ; }

				if test "${snapshotFailed}" -eq "0" ; then
                        		if test -d /mnt/${lvmSnapshotName} ; then
                                		sysLogger "debug" "\"/mnt/${lvmSnapshotName}\" exists, not creating it..."
                                		else mkdir -p /mnt/${lvmSnapshotName}
                        		fi

                			mount -v ${lvmSnapshotOpts} /dev/${lvmVolumeGroup}/${lvmSnapshotName} /mnt/${lvmSnapshotName} \
					2> >(sysLogger "critical") 1> >(sysLogger "debug") || { errorLevel "fail" ; cleanup ; }
				fi ;;

                destroy)	if test -b /dev/${lvmVolumeGroup}/${lvmSnapshotName} ; then
					umount -v /mnt/${lvmSnapshotName} \
					2> >(sysLogger "critical") 1> >(sysLogger "debug") || errorLevel "warn"

					{	exec  62>&- 63>&-
						lvremove --force ${lvmVolumeGroup}/${lvmSnapshotName}
					} 2> >(sysLogger "critical") 1> >(sysLogger "debug") || errorLevel "warn"
				fi ;;

                *)              echo "option does not exist" ;;  
        esac
}


####### backup engine log compilation
backupEngineLog ()
{
	if test "${backupEngine}" = "tar"; then
		local tarToTest=$1

		sysLogger "info" "=== BACKUP REPORT FOR \"${name}\" ==="
		sysLogger "info" "Backup Engine: ${backupEngine}"

		if test "${tarFailed}" -eq "0" ; then
			tarResult=Success

			sysLogger "info" "Backup Status: ${tarResult}"
			sysLogger "info" "Number of files:  $(tar -tf ${tarToTest} | grep -v '/$' | wc -l | awk '{ gsub(/ /, ""); print }')"
			sysLogger "info" "Total file size: $(du --si ${tarToTest} | cut -f 1) bytes"
			# tar writes to stderr for "--totals". see notes
			outputParser /tmp/tar.stderr

			else tarResult=Failed
			sysLogger "info" "Backup Status: ${tarResult}"
			outputParser /tmp/tar.stderr
		fi
		endTimer
		sysLogger "info" "..."
	fi

	if test "${backupEngine}" = "rsync" ; then

		sysLogger "info" "=== BACKUP REPORT FOR \"${name}\" ==="
		sysLogger "info" "Backup Engine: ${backupEngine}"

		if test "${rsyncFailed}" -eq "0" ; then
			rsyncResult=Success

				if test "${logLevel}" = "debug" ; then
					outputParser /tmp/rsync.stdout
					else
					sysLogger "info" "Backup Status: ${rsyncResult}"
					sysLogger "info" "$(grep 'Number of files:' /tmp/rsync.stdout)"
					sysLogger "info" "$(grep 'Number of files transferred:' /tmp/rsync.stdout)"
					sysLogger "info" "$(grep 'Total file size:' /tmp/rsync.stdout)"
					sysLogger "info" "$(grep 'Total transferred file size:' /tmp/rsync.stdout)"
					sysLogger "info" "$(grep 'bytes/sec' /tmp/rsync.stdout)"
				fi

			else rsyncResult=Failed
			sysLogger "info" "Backup Status: ${rsyncResult}"
			outputParser /tmp/rsync.stderr
		fi
		endTimer
		sysLogger "info" "..."
		
	fi
}


####### function to use rsync as backup engine
backupUsingrsync ()
{
	rsyncFailed=0
	rsyncExclusions=$(IFS=, ; for excl in ${exclude} ; do printf "%s" "--exclude=\"$excl\" " ; done)

		if test "${backupRotationCount}" -ne "1" && test "${rsyncDeleteDest}" = "false" ; then
			rsyncOptions="$rsyncOptions --link-dest=${backupDeviceMount}/${backupDir}/${backupSubDir}/${rsyncLinkDest}/${name}"
		fi

		if test "${backupRotationCount}" -eq "1" && test "${rsyncDeleteDest}" = "true" ; then
			rm -fr "${backupDeviceMount}/${backupDir}/${backupSubDir}/${backupDirFormat}/${name}" &
			wait
		fi

	startTimer
	eval rsync --stats "${rsyncExclusions}" ${rsyncOptions} ./ ${backupDeviceMount}/${backupDir}/${backupSubDir}/${backupDirFormat}/${name} \
	2> /tmp/rsync.stderr 1> /tmp/rsync.stdout || { errorLevel "fail" ; rsyncFailed=1 ; }
	backupEngineLog
}


####### function to use tar as backup engine
backupUsingtar ()
{
	local tarArchive=${backupDeviceMount}/${backupDir}/${backupSubDir}/${backupDirFormat}/${name}.tar.gz

	tarFailed=0
	tarExclusions=$(IFS=, ; for excl in ${exclude} ; do printf "%s" "--exclude=\"$excl\" " ; done)

	startTimer
	eval tar --totals ${tarOptions} ${tarArchive} ./ ${tarExclusions} \
	2> /tmp/tar.stderr 1> /tmp/tar.stdout || { errorLevel "fail" ; tarFailed=1 ; }
	backupEngineLog ${tarArchive}
}


####### 
# START SCRIPT
####### 


####### log marker start
loggerStart=$(date +%s)
sysLogger "info" "Begin ${scriptName} at ${loggerStart}"
sysLogger "info" "${scriptName} Started at $(date +%F" "%H:%M)"
sysLogger "info" "..."


####### pre execution checks
test $(id -u) = 0 || { sysLogger "critical" "${scriptName} must be run as root" ; cleanup ; }
test -d /var/lock/mugsyback && { sysLogger "critical" "${scriptName} lock dir exists, is ${scriptName} still running?" ; cleanup ; }
test -x $(which rsync) || { sysLogger "critical" "rsync executable not found" ; cleanup ; }
test -x $(which tar) || { sysLogger "critical" "tar executable not found" ; cleanup ; }
test -x $(which tree) || { sysLogger "critical" "tree executable not found" ; cleanup ; }

if test "${enableEmail}" -eq "1" ; then
	test -x $(which mail) || { sysLogger "critical" "mail executable not found" ; cleanup ; }
fi

test -s ${mugsybackConf} || { sysLogger "critical" "could not find or parse mugsyback config file, aborting..." ; errorLevel "fail" ; cleanup ; }

mkdir /var/lock/mugsyback


####### source global script variables and log discovered sources
eval $(sed -e '/^ *#/d;s/#.*//' -e "0,/\[globalScriptVariables\]/d" -e "/\[::\]/,\$d" ${mugsybackConf})
backupSources=$(grep "\[backupSource[0-99]\]" ${mugsybackConf} | wc -l)
# how many backup sources? this needs more error checking for mistakes by the user
sysLogger "debug" "discovered ${backupSources} Backup Sources"


####### mount backup target
# this might be moved inside the while loop below to allow multiple backup targets
mountBackupTarget ${backupDeviceMount}


####### remove folders older than backupRotationCount
checkBackupRotation ${backupDeviceMount}/${backupDir}/${backupSubDir} \
|| { sysLogger "critical" "backup rotation failed" ; errorLevel "fail" ; cleanup ; }


####### begin backups
while test "${backupSources}" -ge "1" ; do

	eval $(sed -e '/^ *#/d;s/#.*//' -e "0,/\[backupSource${backupSources}\]/d" -e "/\[::\]/,\$d" ${mugsybackConf})

		if test "${lvmSnapshotBackup}" = "true"; then

			lvmControlSnapshot create

			if test "${snapshotFailed}" -eq "0"; then
				backupSourcePath=/mnt/${lvmSnapshotName}${source}
				cd ${backupSourcePath}
				backupUsing${backupEngine}
				sync &
				wait
				cd /
			fi

			lvmControlSnapshot destroy
		fi

		if test "${lvmSnapshotBackup}" = "false"; then

			backupSourcePath=${source}
			cd ${backupSourcePath}
			backupUsing${backupEngine}
			sync &
			wait
			cd /
		fi

		unset 	name \
			source \
			exclude \
			backupEngine \
			lvmSnapshotBackup \
			lvmSnapshotSize \
			lvmVolumeGroup \
			lvmName \
			lvmSnapshotName \
			lvmSnapshotOpts \
			backupSourcePath \
			rsyncExclusions \
			rsyncOptions \
			tarOptions

		rsyncDeleteDest="false"
	
	backupSources=$[${backupSources}-1]
done


####### cleanup
cleanup


####### END ####### 
